Pro ASP.NET Core MVC2(第7版)翻译

第29章:ASP.NET Core Identity 应用

作者:Adam Freeman
翻译:陈广
日期:2018-10-28


本章我将向您展示如何应用 ASP.NET Core Identity 对上一章中创建的用户帐户进行身份验证和授权。表29-1为本章概述。

表 29-1:本章摘要

问题 解决方案 清单
限制对 action 方法的访问 应用Authorize特性 1
用户验证 创建一个 Account 控制器,接收用户凭据并使用UserManager类检查它们。 2-5
创建并管理角色 使用RoleManager 6-10
授权使用角色访问 action 方法 将用户帐户添加到角色中,并使用Authorize特性指定哪些角色可以访问 action 方法 11-18
确保有一个管理帐户 为数据库添加种子以自动创建帐户 19-24

准备示例项目

在本章中,我将继续研究我在第28章中创建的用户项目。为了准备本章,运行应用程序,导航到 /Admin URL,并使用【Create】按钮确保表29-2中的用户帐户位于数据库中。

表 29-2:本章所需的用户帐户

用户名 Email 密码
Joe joe@example.com secret123
Alice alice@example.com secret123
Bob bob@example.com secret123

当您完成之后,请求 /Admin URL 会向您显示一个用户列表,包括表29-2中描述的用户列表(如果您创建了额外的用户并不要紧,只要表中的用户是存在的),如图29-1所示。

图29-1 运行示例应用程序

验证用户

ASP.NET Core Identity 最基本的活动是对用户进行身份验证。限制对 action 方法访问的关键工具是Authorize特性,它告诉 MVC 只应处理来自经过身份验证的用户的请求。在清单29-1中,我将Authorize特性应用于 Home 控制器的Index action。

清单 29-1:Controllers 文件夹下的 HomeController.cs 文件,限制访问

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;

namespace Users.Controllers
{
    public class HomeController : Controller
    {
        [Authorize]
        public ViewResult Index() =>
            View(new Dictionary<string, object>
                { ["Placeholder"] = "Placeholder" });
    }
}

如果启动应用程序,浏览器将向默认的 URL 发送请求,该请求将针对已被授权属性修饰的操作方法。目前用户无法对自己进行身份验证,结果是图29-2中所示的错误。

图29-2 针对受保护的 action 方法

Authorize特性没有指定如何对用户进行身份验证,也没有与 ASP.NET Core Identity 的直接链接。身份服务和中间件跨 ASP.NET Core 平台工作,使集成到 MVC 应用程序变得简单和无缝,并通过修改描述 HTTP 请求的 context 对象来工作,向 MVC 提供身份验证过程结果的详细信息,而无需提供任何细节。

ASP.NET Core 平台通过HttpContext对象提供有关用户的信息,Authorize特性使用HttpContext对象检查当前请求的状态,并查看用户是否已通过身份验证。HttpContext.User属性返回IPrincipal接口的实现,该实现在System.Security.Principal命名空间中定义。IPrincipal接口定义了表29-3中所示的属性和方法。

表 29-3:由 IPrincipal 接口定义的选定成员

名称 描述
Identity 返回IIdentity接口的实现以描述与用户关联的请求
IsInRole(role) 如果用户是指定角色的成员,则返回true。有关使用角色管理授权的详细信息,请参阅《使用角色授权用户》一节。

IPrincipal.Identity属性返回的IIdentity接口的实现通过我在表29-4中描述的属性提供了一些关于当前用户的基本但有用的信息。

表 29-4:IIdentity接口定义的选定属性

名称 描述
AuthenticationType 返回一个字符串,描述用于验证用户的机制。
IsAuthenticated 如果用户已通过身份验证,则返回true
Name 返回当前用户的名称

提示:在第30章中,我将描述 ASP.NET Core identity 用于IIdentity接口的实现类。

ASP.NET Core Identity 中间件使用浏览器发送的 cookies 来确定用户是否已通过身份验证。如果已对用户进行身份验证,则IIdentity.IsAuthenticated属性将设置为true。由于示例应用程序还没有身份验证机制,因此IsAuthenticated属性始终返回false,这将导致身份验证错误,导致将客户机重定向到 /Account/Login URL,这是用于提供身份验证凭据的默认 URL。

浏览器请求 /Account/Login URL,但由于它不对应于示例项目中的任何控制器或 action,因此服务器返回【404 – Not Found】响应,导致如图29-2所示的错误消息。


更改登录 URL

尽管 /Account/Login 是客户端在需要授权时重定向到的默认 URL,但您可以通过在设置 ASP.NET Core identity 服务时更改配置选项,在Startup类的ConfigureServices方法中指定自己的 URL,如下所示:

...
services.ConfigureApplicationCookie(opts => opts.LoginPath = "/Users/Login");
...

identity 系统无法依赖于存在的路由系统来生成它的 URL,因此必须逐字逐句地指定重定向目标。如果更改应用程序使用的路由方案,还必须确保更改 identity 设置,以便 URL 仍然到达目标控制器。


准备实现身份验证

即使请求以错误消息结尾,但上一节中的请求说明了 ASP.NET Core Identity 系统如何适应标准的 ASP.NET 请求生命周期。下一步是实现一个控制器,该控制器将接收 /Account/Login URL 的请求,并对用户进行身份验证。我首先将一个新的模型类添加到 UserViewModels.cs 文件中,如清单29-2所示。

清单 29-2:Models 文件夹下的 UserViewModels.cs 文件,添加新的模型类

using System.ComponentModel.DataAnnotations;

namespace Users.Models
{
    public class CreateModel
    {
        [Required]
        public string Name { get; set; }
        [Required]
        public string Email { get; set; }
        [Required]
        public string Password { get; set; }
    }

    public class LoginModel
    {
        [Required]
        [UIHint("email")]
        public string Email { get; set; }

        [Required]
        [UIHint("password")]
        public string Password { get; set; }
    }
}

新模型具有EmailPassword特性,这两个特性都带有Required特性,因此我可以使用模型验证来检查用户是否提供了值。我已经用UIHint特性修饰了属性,它确保视图中的标签助手渲染的input元素的type属性将被适当设置。

提示:在实际项目中,客户端验证可用于在向服务器提交表单之前检查用户是否提供了名称和密码值。有关客户端验证的详细信息,请参阅第27章。

我在 Controllers 文件夹中添加了一个名为 AccountController.cs 的类文件,并使用它来定义如清单29-3所示的控制器。

清单 29-3:Controllers 文件夹下的 AccountController.cs 文件的内容

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Users.Models;

namespace Users.Controllers
{
    [Authorize]
    public class AccountController : Controller
    {
        [AllowAnonymous]
        public IActionResult Login(string returnUrl)
        {
            ViewBag.returnUrl = returnUrl;
            return View();
        }

        [HttpPost]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Login(LoginModel details, string returnUrl)
        {
            return View(details);
        }
    }
}

我没有在清单中实现身份验证逻辑,因为我将定义视图,然后遍历验证用户凭证的过程,并将用户签名到应用程序。

尽管 Account 控制器还没有对用户进行身份验证,但它包含一些有用的基础结构,我希望将这些基础结构与 ASP.NET Core Identity 代码分开解释,稍后我将其添加到Login action 方法中。

首先,请注意,Login action 方法的两个版本都采用了一个称为returnUrl的参数。当用户请求受限制的 URL 时,会使用查询字符串将它们重定向到 /Account/Login URL,该查询字符串指定用户在经过身份验证后应该被发送回的 URL。如果启动应用程序并请求 /Home/Index URL,则可以看到这一点。您的浏览器将被重定向,如下所示:

/Account/Login?ReturnUrl=%2FHome%2FIndex

ReturnUrl查询字符串参数的值允许我重定向用户,以便在应用程序的打开部分和安全部分之间导航是一个平滑和无缝的过程。

接下来,注意我应用于 Account 控制器的特性。管理用户帐户的控制器包含仅对经过身份验证的用户可用的功能,例如密码重置。为此,我对控制器类应用了Authorize特性,然后在各个 action 方法上使用了AllowAnonymous特性。这使得默认情况下将 action 方法限制为身份验证用户,但允许未经身份验证的用户登录应用程序。我应用了ValidateAntiForgeryToken特性,我在第24章中描述了这个特性,它与form元素标签助手一起工作,以防止跨站点请求伪造。

最后的准备步骤是创建将渲染的视图,以收集用户的凭据。我创建了 Views/Account 文件夹,并添加了一个名为 Login.cshtml 的视图,其标记如清单29-4所示。

清单 29-4:Views/Account 文件夹下的 Login.cshtml 文件的内容

@model LoginModel

<div class="bg-primary m-1 p-1 text-white"><h4>Log In</h4></div>

<div class="text-danger" asp-validation-summary="All"></div>

<form asp-action="Login" method="post">
    <input type="hidden" name="returnUrl" value="@ViewBag.returnUrl" />
    <div class="form-group">
        <label asp-for="Email"></label>
        <input asp-for="Email" class="form-control" />
    </div>
    <div class="form-group">
        <label asp-for="Password"></label>
        <input asp-for="Password" class="form-control" />
    </div>
    <button class="btn btn-primary" type="submit">Log In</button>
</form>

此视图的唯一值得注意的方面是隐藏的input元素,它保留了returnUrl参数。在所有其他方面,这是一个标准的 Razor 视图,但它完成了身份验证的准备工作,并演示了拦截和重定向未经身份验证的请求的方式。若要测试新控制器,请启动应用程序。当浏览器请求应用程序的默认 URL 时,它将被重定向到 /Account/Login URL,这将生成如图29-3所示的内容。

图29-3 提示用户提供凭据

添加用户身份验证

对受保护 action 方法的请求正被正确地重定向到 Account 控制器,但用户提供的凭据尚未用于身份验证。在清单29-5中,我已经完成了Login action 的实现,使用 ASP.NET Core Identity 服务根据数据库中的详细信息对用户进行身份验证。

清单 29-5:Controllers 文件夹下的 AccountController.cs 文件,添加用户验证

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Users.Models;
using Microsoft.AspNetCore.Identity;

namespace Users.Controllers
{
    [Authorize]
    public class AccountController : Controller
    {
        private UserManager<AppUser> userManager;
        private SignInManager<AppUser> signInManager;

        public AccountController(UserManager<AppUser> userMgr,
            SignInManager<AppUser> signinMgr)
        {
            userManager = userMgr;
            signInManager = signinMgr;
        }

        [AllowAnonymous]
        public IActionResult Login(string returnUrl)
        {
            ViewBag.returnUrl = returnUrl;
            return View();
        }

        [HttpPost]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Login(LoginModel details,
            string returnUrl)
        {
            if (ModelState.IsValid)
            {
                AppUser user = await userManager.FindByEmailAsync(details.Email);
                if (user != null)
                {
                    await signInManager.SignOutAsync();
                    Microsoft.AspNetCore.Identity.SignInResult result =
                        await signInManager.PasswordSignInAsync(
                            user, details.Password, false, false);
                    if (result.Succeeded)
                    {
                        return Redirect(returnUrl ?? "/");
                    }
                }
                ModelState.AddModelError(nameof(LoginModel.Email),
                "Invalid user or password");
            }
            return View(details);
        }
    }
}

最简单的部分是获取表示用户的AppUser对象,这是通过UserManager<AppUser>类的FindByEmailAsync方法实现的。

...
AppUser user = await userManager.FindByEmailAsync(details.Email);
...

此方法使用用于创建用户帐户的电子邮件地址定位用户帐户。有其他方法可以通过 ID、用户名和登录来定位用户。我使用电子邮件地址进行登录,因为它是大多数面向互联网的 web 应用程序采用的方法,并且在公司应用程序中也很流行。

如果有一个帐户具有用户指定的电子邮件地址,那么下一步是执行身份验证步骤,这是使用SignInManager<AppUser>类完成的,为此我添加了一个构造函数参数,它将使用依赖注入进行解析。我使用SignInManager类执行两个身份验证步骤。

...
await signInManager.SignOutAsync();
Microsoft.AspNetCore.Identity.SignInResult result =
    await signInManager.PasswordSignInAsync(user, details.Password, false, false);
...

SignOutAsync方法取消用户拥有的任何现有会话,PasswordSignIn方法执行身份验证。PasswordSignInAsync方法参数为:用户对象,用户提供的密码,一个bool参数,它控制身份验证 cookie 是否是持久的(我禁用了它),如果密码正确,帐户是否应该被锁定(我也禁用了)。

PasswordSignInAsync方法的结果为一个SignInResult对象,它定义了一个布尔类型的Succeeded属性,指示身份验证进程是否成功。

在该示例中,我检查Succeeded属性并将用户重定向到returnUrl位置(如果为true),添加验证错误并向用户重新显示Login视图,以便他们可以再次尝试。

作为身份验证过程的一部分,Identity 将 cookie 添加到响应中,然后浏览器将其包含在任何后续请求中,并用于标识用户会话和与其关联的帐户。您不必直接创建或管理 cookie,因为它是由 Identity 中间件自动处理的。


考虑双因素认证

我在本章中执行了单因素身份验证,这是用户能够使用他们预先知道的一条信息进行身份验证的地方:密码。

ASP.NET Core identity 还支持双因素身份验证,其中用户需要额外的东西,通常是在用户想要进行身份验证时提供给他们的东西。最常见的例子是来自 Secureid 令牌的值或作为电子邮件或文本消息发送的身份验证代码(严格地说,这两个因素可以是任何因素,包括指纹、虹膜扫描和语音识别,尽管这些选项对于大多数 web 应用程序来说很少需要)。

增加安全性是因为攻击者需要知道用户的密码,并且能够访问任何提供第二个因素的内容,例如电子邮件帐户或手机。

由于两个原因,我没有在书中显示双因素认证。首先,它需要做大量的准备工作,比如建立分发第二因素电子邮件和文本的基础设施,以及实现验证逻辑,所有这些都超出了本书的范围。

第二个原因是双重身份验证迫使用户记住跳过一个额外的循环来进行身份验证,例如记住他们的电话或在附近保留一个安全令牌,这对于 web 应用程序来说并不总是合适的。十多年来,我在各种工作中都带着一种或另一种安全标识,我忘记了我无法登录到雇主系统的次数,因为我把令牌忘在家里了。

如果您对双因素安全性感兴趣,那么我建议您依赖第三方提供商(如 Google)进行身份验证,这样用户就可以选择是否需要双因素身份验证所提供的附加安全性(和不便)。我在第30章中演示了第三方认证。


测试认证

要测试用户身份验证,请启动应用程序并请求 /Home/Index URL。当重定向到 /Account/Login URL 时,输入我在本章开头列出的一个用户的详细信息(例如,电子邮件地址 joe@example.com 和密码 secret123)。单击【Log In】按钮,浏览器将被重定向回 /Home/Index URL,但这一次它将提交授予它访问 action 方法的身份验证 cookie,如图29-4所示。

图29-4 验证一个用户

提示:您可以使用浏览器的开发工具来查看用于标识经过身份验证的请求的 cookie。

向用户授权角色

在上一节中,Authorize特性以其最基本的形式使用,它允许任何经过身份验证的用户执行 action 方法。它还可以用于细化授权,根据用户的角色成员身份,对哪些用户可以执行哪些操作进行细粒度控制。

角色只是一个任意标签,您可以定义它来表示在应用程序中执行一组活动的权限。几乎每个应用程序都区分能够执行管理功能的用户和不能执行管理功能的用户。在角色世界中,这是通过创建管理员角色并为其分配用户来完成的。用户可以属于许多角色,与角色关联的权限可以是粗的或细的,因此可以使用单独的角色来区分可以执行基本任务(例如创建新帐户)的管理员和可以执行更敏感操作(例如访问支付数据)的管理员。

ASP.NET Core Identity 负责管理应用程序中定义的角色集,并跟踪每个用户的成员。但是它不知道每个角色意味着什么;这些信息包含在应用程序的 MVC 部分中,在 MVC 部分中,对 action 方法的访问受到基于角色成员资格的限制。

ASP.NET Core Identity 提供了一个强类型基类,用于访问和管理名为RoleManager<T>的角色,其中T是表示存储机制中角色的类。Entity Framework Core 使用一个名为IdentityRole的类来表示角色,它定义了表29-5中描述的属性。

表 29-5:选定的 IdentityRole 属性

名称 描述
Id 定义角色的唯一标识符
Name 定义角色的名称
Users 返回IdentityUserRole对象的集合,用于表示角色的成员。

如果要扩展内置功能,可以创建特定于应用程序的角色类,我在第30章中对用户对象进行了描述,但我将使用IdentityRole类,因为它完成了大多数应用程序所需的一切。当我在第28章中配置应用程序时,我已经告诉 ASP.NET Core Identity 使用IdentityRole来表示角色,正如Startup类的ConfigureServices方法中的这条语句所显示的:

...
services.AddIdentity<AppUser, IdentityRole>(opts => {
    opts.User.RequireUniqueEmail = true;
    //opts.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyz";
    opts.Password.RequiredLength = 6;
    opts.Password.RequireNonAlphanumeric = false;
    opts.Password.RequireLowercase = false;
    opts.Password.RequireUppercase = false;
    opts.Password.RequireDigit = false;
}).AddEntityFrameworkStores<AppIdentityDbContext>()
    .AddDefaultTokenProviders();
...

AddIdentity方法的类型参数指定将用于表示用户和角色的类。在示例应用程序中,AppUser类用于表示用户,内置的IdentityRole类用于角色。

创建和删除角色

为了演示如何使用角色,我将创建一个用于管理角色的管理工具,首先创建可以创建和删除角色的 action 方法。我在 Controllers 文件夹中添加了一个名为 RoleAdminController.cs 的类文件,并使用它来定义如清单29-6所示的控制器。

清单 29-6:Controllers 文件夹下的 RoleAdminController.cs 文件的内容

using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;

namespace Users.Controllers
{
    public class RoleAdminController : Controller
    {
        private RoleManager<IdentityRole> roleManager;

        public RoleAdminController(RoleManager<IdentityRole> roleMgr)
        {
            roleManager = roleMgr;
        }

        public ViewResult Index() => View(roleManager.Roles);
        public IActionResult Create() => View();

        [HttpPost]
        public async Task<IActionResult> Create([Required]string name)
        {
            if (ModelState.IsValid)
            {
                IdentityResult result
                    = await roleManager.CreateAsync(new IdentityRole(name));
                if (result.Succeeded)
                {
                    return RedirectToAction("Index");
                }
                else
                {
                    AddErrorsFromResult(result);
                }
            }
            return View(name);
        }

        [HttpPost]
        public async Task<IActionResult> Delete(string id)
        {
            IdentityRole role = await roleManager.FindByIdAsync(id);
            if (role != null)
            {
                IdentityResult result = await roleManager.DeleteAsync(role);
                if (result.Succeeded)
                {
                    return RedirectToAction("Index");
                }
                else
                {
                    AddErrorsFromResult(result);
                }
            }
            else
            {
                ModelState.AddModelError("", "No role found");
            }
            return View("Index", roleManager.Roles);
        }

        private void AddErrorsFromResult(IdentityResult result)
        {
            foreach (IdentityError error in result.Errors)
            {
                ModelState.AddModelError("", error.Description);
            }
        }
    }
}

角色使用RoleManager<T>类进行管理,其中T是用于表示角色的类型(此应用程序的内置IdentityRole类)。RoleAdminController构造器声明了一个RoleManager<IdentityRole>构造函数依赖项,在创建控制器时使用依赖注入来解析该依赖项。

RoleManager<T>类定义了表29-6中所示的方法和属性,这些方法和属性允许创建和管理角色。

表 29-6:RoleManager<T>类定义的成员

名称 描述
CreateAsync(role) 创建一个新角色
DeleteAsync(role) 删除指定角色
FindByIdAsync(id) 查找指定 ID 的角色
FindByNameAsync(name) 查找指定名称的角色
RoleExistsAsync(name) 如果存在具有指定名称的角色,则返回true
UpdateAsync(role) 存储对指定角色的更改
Roles 返回已定义的角色的枚举

新控制器的Index action 方法显示应用程序中的所有角色。Create action 方法用于显示和接收表单,其中的数据用于使用CreateAsync方法创建一个新角色。Delete action 方法接收 POST 请求并接收角色的唯一 ID,该 ID 用于使用DeleteAsync方法将其从应用程序中删除,并使用FindByIdAsync方法定位了表示该请求的对象。

创建视图

为了显示应用程序中角色的详细信息,我创建了 Views/RoleAdmin 文件夹,并使用清单29-7所示的标记添加了 Index.cshtml 文件。

清单 29-7:Views/RoleAdmin 文件夹下的 Index.cshtml 文件的内容

@model IEnumerable<IdentityRole>

<div class="bg-primary m-1 p-1"><h4>Roles</h4></div>

<div class="text-danger" asp-validation-summary="ModelOnly"></div>

<table class="table table-sm table-bordered table-bordered">
    <tr><th>ID</th><th>Name</th><th>Users</th><th></th></tr>
    @if (Model.Count() == 0)
    {
        <tr><td colspan="4" class="text-center">No Roles</td></tr>
    }
    else
    {
        foreach (var role in Model)
        {
            <tr>
                <td>@role.Id</td>
                <td>@role.Name</td>
                <td identity-role="@role.Id"></td>
                <td>
                    <form asp-action="Delete" asp-route-id="@role.Id" method="post">
                        <a class="btn btn-sm btn-primary" asp-action="Edit"
                           asp-route-id="@role.Id">Edit</a>
                        <button type="submit"
                                class="btn btn-sm btn-danger">
                            Delete
                        </button>
                    </form>
                </td>
            </tr>
        }
    }
</table>
<a class="btn btn-primary" asp-action="Create">Create</a>

此视图使用表格显示应用程序中角色的详细信息。第三列使用自定义元素属性,如下所示:

...
<td identity-role="@role.Id"></td>
...

我想显示每个角色的用户列表,这需要在视图中包含太多的代码。为了保持视图的简单性,我向 Infrastructure 文件夹中添加了一个名为 RoleUsersTagHelper.cs 的类文件,并使用它定义了清单29-8所示的标签助手。

清单 29-8:Infrastructure 文件夹下的 RoleUsersTagHelper.cs 文件的内容

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Users.Models;

namespace Users.Infrastructure
{
    [HtmlTargetElement("td", Attributes = "identity-role")]
    public class RoleUsersTagHelper : TagHelper
    {
        private UserManager<AppUser> userManager;
        private RoleManager<IdentityRole> roleManager;

        public RoleUsersTagHelper(UserManager<AppUser> usermgr,
            RoleManager<IdentityRole> rolemgr)
        {
            userManager = usermgr;
            roleManager = rolemgr;
        }

        [HtmlAttributeName("identity-role")]
        public string Role { get; set; }

        public override async Task ProcessAsync(TagHelperContext context,
            TagHelperOutput output)
        {
            List<string> names = new List<string>();
            IdentityRole role = await roleManager.FindByIdAsync(Role);
            if (role != null)
            {
                foreach (var user in userManager.Users)
                {
                    if (user != null
                        && await userManager.IsInRoleAsync(user, role.Name))
                    {
                        names.Add(user.UserName);
                    }
                }
            }
            output.Content.SetContent(names.Count == 0 ?
                "No Users" : string.Join(", ", names));
        }
    }
}

这个标签助手使用了一个identity-role特性对td元素进行操作,该属性用于接收正在处理的角色的名称。RoleManager<IdentityRole>UserManager<AppUser>对象允许对身份数据库的查询在角色中构建用户名列表。在清单29-9中,我将标签助手添加到视图导入文件中,并添加了一个@using表达式,这样我就可以在不使用命名空间的情况下引用视图中的 EF Core 类型。

清单 29-9:Views 文件夹下的 _ViewImports.cshtml 文件

@using Users.Models
@using Microsoft.AspNetCore.Identity
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper Users.Infrastructure.*, Users

接下来,我将一个名为 Create.cshtml 的视图添加到 Views/RoleAdmin 文件夹中,并添加了清单29-10中所示的标记,以支持添加新角色。

清单 29-10:Views/RoleAdmin 文件夹下的 Create.cshtml 文件的内容

@model string

<div class="bg-primary m-1 p-1"><h4>Create Role</h4></div>

<div asp-validation-summary="All" class="text-danger"></div>

<form asp-action="Create" method="post">
    <div class="form-group">
        <label for="name"></label>
        <input name="name" class="form-control" />
    </div>
    <button type="submit" class="btn btn-primary">Create</button>
    <a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>

创建角色所需的唯一表单数据是名称,这就是为什么我能够在 Create.cshtml 视图中使用一个字符串作为视图模型类。我想利用模型验证来确保用户在表单提交时提供一个值,但是不值得为这样一个简单的任务创建一个专用的模型类。相反,如果您查看清单29-6中接受 POST 请求的Create方法,将看到我已经将所需的验证属性直接应用于参数。这与在模型类中应用属性具有相同的效果,并允许我利用内置模型验证过程。

测试、创建和删除角色

要测试新控制器,请启动应用程序并导航到 /RoleAdmin URL。单击【Create】按钮,在input元素中输入一个名称,然后单击第二个【Create】按钮。新角色将保存到数据库中,并在浏览器重定向到Index action 时显示,如图29-5所示。您可以通过单击【Delete】按钮从应用程序中删除该角色。

图29-5 创建一个新角色

管理角色成员资格

下一步是能够从角色中添加和删除用户。这不是一个复杂的过程,但它调用来自RoleManager类的角色数据,并将其与单个用户的详细信息相关联。

首先,我定义了一些视图模型类,这些类将表示角色的成员资格,并从用户那里接收一组新的成员资格指令。清单29-11显示了我对 Models 文件夹中的 UserViewModels.cs 文件所做的添加。

清单 29-11:UserViewModels.cs 文件,添加视图模型

using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using Microsoft.AspNetCore.Identity;

namespace Users.Models
{
    public class CreateModel
    {
        [Required]
        public string Name { get; set; }
        [Required]
        public string Email { get; set; }
        [Required]
        public string Password { get; set; }
    }

    public class LoginModel
    {
        [Required]
        [UIHint("email")]
        public string Email { get; set; }

        [Required]
        [UIHint("password")]
        public string Password { get; set; }
    }

    public class RoleEditModel
    {
        public IdentityRole Role { get; set; }
        public IEnumerable<AppUser> Members { get; set; }
        public IEnumerable<AppUser> NonMembers { get; set; }
    }

    public class RoleModificationModel
    {
        [Required]
        public string RoleName { get; set; }
        public string RoleId { get; set; }
        public string[] IdsToAdd { get; set; }
        public string[] IdsToDelete { get; set; }
    }
}

RoleEditModel类表示系统中用户的角色和详细信息,按用户是否是角色的成员进行分类。RoleModificationModel类表示对角色的一组更改。

清单29-12显示了 RoleAdmin 控制器中添加的新 action 方法,这些方法使用清单29-11中的视图模型来管理角色成员关系。

清单 29-12:Controllers 文件夹下的 RoleAdminController.cs 文件,添加 Action 方法

using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
using Users.Models;
using System.Collections.Generic;

namespace Users.Controllers
{
    public class RoleAdminController : Controller
    {
        private RoleManager<IdentityRole> roleManager;
        private UserManager<AppUser> userManager;

        public RoleAdminController(RoleManager<IdentityRole> roleMgr,
            UserManager<AppUser> userMrg)
        {
            roleManager = roleMgr;
            userManager = userMrg;
        }

        // ...其它 action 方法省略...

        public async Task<IActionResult> Edit(string id)
        {
            IdentityRole role = await roleManager.FindByIdAsync(id);
            List<AppUser> members = new List<AppUser>();
            List<AppUser> nonMembers = new List<AppUser>();
            foreach (AppUser user in userManager.Users)
            {
                var list = await userManager.IsInRoleAsync(user, role.Name)
                    ? members : nonMembers;
                list.Add(user);
            }
            return View(new RoleEditModel
            {
                Role = role,
                Members = members,
                NonMembers = nonMembers
            });
        }

        [HttpPost]
        public async Task<IActionResult> Edit(RoleModificationModel model)
        {
            IdentityResult result;
            if (ModelState.IsValid)
            {
                foreach (string userId in model.IdsToAdd ?? new string[] { })
                {
                    AppUser user = await userManager.FindByIdAsync(userId);
                    if (user != null)
                    {
                        result = await userManager.AddToRoleAsync(user,
                            model.RoleName);
                        if (!result.Succeeded)
                        {
                            AddErrorsFromResult(result);
                        }
                    }
                }
                foreach (string userId in model.IdsToDelete ?? new string[] { })
                {
                    AppUser user = await userManager.FindByIdAsync(userId);
                    if (user != null)
                    {
                        result = await userManager.RemoveFromRoleAsync(user,
                            model.RoleName);
                        if (!result.Succeeded)
                        {
                            AddErrorsFromResult(result);
                        }
                    }
                }
            }
            if (ModelState.IsValid)
            {
                return RedirectToAction(nameof(Index));
            }
            else
            {
                return await Edit(model.RoleId);
            }
        }

        private void AddErrorsFromResult(IdentityResult result)
        {
            foreach (IdentityError error in result.Errors)
            {
                ModelState.AddModelError("", error.Description);
            }
        }
    }
}

Edit action 方法的 GET 版本中的大多数代码负责生成所选角色的成员和非成员的集合。一旦对所有用户进行了分类,就会向视图方法传递RoleEditModel模型类的一个新实例,以便可以使用默认视图显示数据。

Edit方法的 POST 版本负责在角色之间添加和删除用户。UserManager<T>类提供了处理角色的方法,我在表29-7中对此进行了描述。

表 29-7:UserManager<T>类定义的角色相关方法

名称 描述
AddToRoleAsync(user, name) 将用户 ID 添加到具有指定名称的角色中
GetRolesAsync(user) 返回用户是其成员的角色名称列表
IsInRoleAsync(user, name) 如果用户是具有指定名称的角色成员,则返回true
RemoveFromRoleAsync(user, name) 从具有指定名称的角色中移除作为成员的用户

这些方法的一个奇怪之处是,与角色相关的方法对角色名称进行操作,尽管角色也有唯一的标识符。正因为如此,我的RoleModificationModel视图模型类具有RoleName属性。

清单29-13显示了 Edit.cshtml 文件的内容,我将该文件添加到 Views/RoleAdmin 文件夹中,并用于定义允许用户编辑角色成员关系的标记。

清单 29-13:Views/RoleAdmin 文件夹下的 Edit.cshtml 文件的内容

@model RoleEditModel

<div class="bg-primary m-1 p-1 text-white"><h4>Edit Role</h4></div>

<div asp-validation-summary="All" class="text-danger"></div>

<form asp-action="Edit" method="post">
    <input type="hidden" name="roleName" value="@Model.Role.Name" />
    <input type="hidden" name="roleId" value="@Model.Role.Id" />

    <h6 class="bg-info p-1 text-white">Add To @Model.Role.Name</h6>
    <table class="table table-bordered table-sm">
        @if (Model.NonMembers.Count() == 0)
        {
            <tr><td colspan="2">All Users Are Members</td></tr>
        }
        else
        {
            @foreach (AppUser user in Model.NonMembers)
            {
                <tr>
                    <td>@user.UserName</td>
                    <td>
                        <input type="checkbox" name="IdsToAdd" value="@user.Id">
                    </td>
                </tr>
            }
        }
    </table>

    <h6 class="bg-info p-1 text-white">Remove From @Model.Role.Name</h6>
    <table class="table table-bordered table-sm">
        @if (Model.Members.Count() == 0)
        {
            <tr><td colspan="2">No Users Are Members</td></tr>
        }
        else
        {
            @foreach (AppUser user in Model.Members)
            {
                <tr>
                    <td>@user.UserName</td>
                    <td>
                        <input type="checkbox" name="IdsToDelete" value="@user.Id">
                    </td>
                </tr>
            }
        }
    </table>
    <button type="submit" class="btn btn-primary">Save</button>
    <a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>

该视图包含两个表:一个用于非所选角色成员的用户,另一个用于所选角色成员的用户。显示每个用户的名称以及允许更改成员资格的复选框。这些表包含在一个表单中,该表单被发送到绑定到RoleModificationModel类的Edit action 方法和模型,从而提供了对要进行的角色成员资格更改列表的轻松访问。

测试和编辑角色成员资格

要测试角色成员资格功能,启动应用程序,导航到 /RoleAdmin URL,并在需要时创建一个名为 User 的新角色。单击【Edit】按钮,您将看到应用程序中的用户显示在非成员列表中,如图29-6所示。

图29-6 显示并编辑角色成员资格

选中复选框以添加 Alice 和 Joe(在本章开头添加到 Identity 系统的两个帐户)并单击【Save】按钮。在角色列表中,您现在将在成员列表中看到 Alice 和 Joe,如图29-7所示。

图29-7 管理角色成员资格

使用角色进行授权

既然应用程序有了角色,就可以使用它们作为通过Authorize特性进行授权的基础。为了更容易地测试基于角色的授权,我向 Account 控制器添加了一个Logout方法,如清单29-14所示,这样就可以退出并以不同的用户身份重新登录,以查看角色成员资格的效果。

清单 29-14:Controllers 文件夹下的 AccountController.cs 文件,添加退出方法

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Users.Models;
using Microsoft.AspNetCore.Identity;

namespace Users.Controllers
{
    [Authorize]
    public class AccountController : Controller
    {
        private UserManager<AppUser> userManager;
        private SignInManager<AppUser> signInManager;

        // ...其它 action 方法省略...
        
        [Authorize]
        public async Task<IActionResult> Logout()
        {
            await signInManager.SignOutAsync();
            return RedirectToAction("Index", "Home");
        }
    }
}

下一步是更新 Home 控制器以添加一个新的 action 方法,并向视图传递一些有关经过身份验证的用户的信息,如清单29-15所示。

清单 29-15:Controllers 文件夹下的 HomeController.cs 文件的增强

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;

namespace Users.Controllers
{
    public class HomeController : Controller
    {
        [Authorize]
        public IActionResult Index() => View(GetData(nameof(Index)));

        [Authorize(Roles = "Users")]
        public IActionResult OtherAction() => View("Index",
            GetData(nameof(OtherAction)));

        private Dictionary<string, object> GetData(string actionName) =>
            new Dictionary<string, object>
            {
                ["Action"] = actionName,
                ["User"] = HttpContext.User.Identity.Name,
                ["Authenticated"] = HttpContext.User.Identity.IsAuthenticated,
                ["Auth Type"] = HttpContext.User.Identity.AuthenticationType,
                ["In Users Role"] = HttpContext.User.IsInRole("Users")
            };
    }
}

Index action 方法的Authorize特性未做更改,但是,在将特性应用于OtherAction方法时,我已经设置了Roles属性,指示只有 Users 角色的成员才能访问它。我还定义了一个GetData方法,它使用通过HttpContext对象提供的属性添加了一些关于用户身份的基本信息。

提示:授权属性还可以用于根据单个用户名列表对访问进行授权。对于小型项目来说,这是一个很有吸引力的特性,但这意味着每次授权更改用户集合时,您都必须更改控制器中的代码,这通常意味着必须再次经历测试和部署周期。使用角色进行授权将应用程序与单个用户帐户的更改隔离开来,并允许您通过 ASP.NET Core Identity 存储的成员来控制对应用程序的访问。

最后的更改是对 Views/Home 文件夹中的 Index.cshtml 文件进行修改,这个文件由 Home 控制器中的两个 action 使用,以添加一个链接,该链接的目标是 Account 控制器中的Logout方法,如清单29-16所示。

清单 29-16:Views/Home 文件夹下的 Index.cshtml 文件,添加一个退出链接

@model Dictionary<string, object>

<div class="bg-primary m-1 p-1 text-white"><h4>User Details</h4></div>

<table class="table table-sm table-bordered m-1 p-1">
    @foreach (var kvp in Model)
    {
        <tr><th>@kvp.Key</th><td>@kvp.Value</td></tr>
    }
</table>

@if (User?.Identity?.IsAuthenticated ?? false)
{
    <a asp-controller="Account" asp-action="Logout"
       class="btn btn-danger">Logout</a>
}

要测试身份验证,启动应用程序并导航到 /Home/Index URL。浏览器将被重定向,以便您可以输入用户凭据。选择使用表29-2中的哪些用户详细信息并不重要,因为应用于Index action 的Authorize特性允许访问任何经过身份验证的用户。

但是,如果您现在请求 /Home/OtherAction URL,从表29-2中选择的用户详细信息将产生影响,因为只有 Alice 和 Joe 是 Users 角色的成员,而 Users 角色是访问OtherAction方法所必需的。如果您以 Bob 身份登录,则浏览器将被重定向到 /Account/AccessDenied URL,当用户无法访问 action 方法时使用该 URL。为了处理这种情况,我向 Account 控制器添加了一个AccessDenied方法,以便有一个处理请求的 action,如清单29-17所示。

提示:您可以通过设置AccessDeniedPath配置属性来更改 /Account/AccessDenied Url。有关类似的示例,请参阅本章前面的《更改登录 URL》侧栏。

清单 29-17:AccountController.cs 文件,添加 action 方法

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Users.Models;
using Microsoft.AspNetCore.Identity;

namespace Users.Controllers
{
    [Authorize]
    public class AccountController : Controller
    {
        private UserManager<AppUser> userManager;
        private SignInManager<AppUser> signInManager;

        public AccountController(UserManager<AppUser> userMgr,
            SignInManager<AppUser> signinMgr)
        {
            userManager = userMgr;
            signInManager = signinMgr;
        }

        // ...其它 action 方法省略...

        [AllowAnonymous]
        public IActionResult AccessDenied()
        {
            return View();
        }
    }
}

为了提供要显示视图的AccessDenied action,我在 Views/Account 文件夹中创建了一个名为 AccessDenied.cshtml 的文件,并添加了清单29-18中所示的内容。

清单 29-18:Views/Account 文件夹下的 AccessDenied.cshtml 文件的内容

<div class="bg-danger mb-1 p-2 text-white"><h4>Access Denied</h4></div>
<a asp-action="Index" asp-controller="Home" class="btn btn-primary">OK</a>

启动应用程序,请求 /Account/Login URL,并以 bob@example.com 进行验证。当身份验证过程完成后,浏览器将被重定向到 /Home/Index URL,它显示帐户的详细信息,如图29-8中的左侧截图所示,这表明 Bob 不是用户 Users 的成员。现在,请求 /Home/OtherAction URL,它以基于角色的访问保护的 action 为目标。Bob 没有必需的角色成员资格,浏览器被重定向到 /Account/AccessDenied URL,如图29-8中的右侧截图所示。

图29-8 使用基于角色的授权

数据库播种

在我的示例项目中,一个挥之不去的问题是,对我的 Admin 和 RoleAdmin 控制器的访问不受限制。这是一个典型的鸡与蛋问题,因为为了限制访问,我需要创建用户和角色,但是 Admin 和 RoleAdmin 控制器是用户管理工具,如果我使用Authorize特性保护它们,就不会有任何凭据授予我对它们的访问权限,特别是在我首次部署应用程序时。

解决此问题的方法是在应用程序启动时用一些初始数据为数据库添加种子。在清单29-19中,我向 appsettings.json 文件添加了一些新的配置数据,以指定将要创建的帐户的详细信息。

清单 29-19:appsettings.json 文件,添加配置数据

{
  "Data": {
    "AdminUser": {
      "Name": "Admin",
      "Email": "admin@example.com",
      "Password": "secret",
      "Role": "Admins"
    },
    "SportStoreIdentity": {
      "ConnectionString": "Server=(localdb)\\MSSQLLocalDB;Database=IdentityUsers; Trusted_Connection=True; MultipleActiveResultSets=true"
    }
  }
}

Data:AdminUser类别提供了创建帐户并将其分配给能够使用管理工具的角色所需的四个值。

警告:将密码放入纯文本配置文件意味着,在首次部署应用程序和初始化新数据库时,必须将密码作为部署过程的一部分来更改默认帐户的密码。

接下来,我将一个静态方法添加到AppIdentityDbContext类中,如清单29-20所示。创建默认帐户的代码不必放在这个类中,但这是我觉得很自然的位置,也是我在自己的项目中使用的位置。您还可以使用一个单独的类,这就是我在 SportsStore 应用程序中所做的。

清单 29-20:Models 文件夹下的 AppIdentityDbContext.cs 文件,添加一个方法

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Threading.Tasks;

namespace Users.Models
{
    public class AppIdentityDbContext : IdentityDbContext<AppUser>
    {
        public AppIdentityDbContext(DbContextOptions<AppIdentityDbContext> options)
            : base(options) { }

        public static async Task CreateAdminAccount(IServiceProvider serviceProvider,
            IConfiguration configuration)
        {
            UserManager<AppUser> userManager =
                serviceProvider.GetRequiredService<UserManager<AppUser>>();
            RoleManager<IdentityRole> roleManager =
                serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();

            string username = configuration["Data:AdminUser:Name"];
            string email = configuration["Data:AdminUser:Email"];
            string password = configuration["Data:AdminUser:Password"];
            string role = configuration["Data:AdminUser:Role"];
            if (await userManager.FindByNameAsync(username) == null)
            {
                if (await roleManager.FindByNameAsync(role) == null)
                {
                    await roleManager.CreateAsync(new IdentityRole(role));
                }
                AppUser user = new AppUser
                {
                    UserName = username,
                    Email = email
                };
                IdentityResult result = await userManager
                    .CreateAsync(user, password);
                if (result.Succeeded)
                {
                    await userManager.AddToRoleAsync(user, role);
                }
            }
        }
    }
}

CreateAdminAccount方法接收一个IServiceProvider对象,用于获取UserManagerRoleManager对象,以及一个IConfiguration对象,用于从 appsetting.json 文件中获取数据。CreateAdminAccount方法中的代码检查用户是否已经存在,如果不存在,则创建它并将其分配给指定的角色,如果需要,也会创建该角色。在清单29-21中,我向Startup类添加了一条语句,在设置和配置了应用程序的其余部分后调用CreateAdminAccount方法。

清单 29-21:Users 文件夹下的 Startup.cs 文件,调用数据库方法

...
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseStatusCodePages();
    app.UseDeveloperExceptionPage();
    app.UseStaticFiles();
    app.UseAuthentication();
    app.UseMvcWithDefaultRoute();
    AppIdentityDbContext.CreateAdminAccount(app.ApplicationServices,
        Configuration).Wait();
}
...

由于我是通过IApplicationBuilder.ApplicationServices提供程序访问作用域服务,所以我还必须禁用程序类中的依赖注入作用域验证功能,如清单29-22所示。

清单 29-22:Users 文件夹下的 Program.cs 文件,禁用作用域验证

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;

namespace Users
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseDefaultServiceProvider(options =>
                    options.ValidateScopes = false);
    }
}

既然标识数据库中有一个可靠的默认帐户,我就可以使用Authorize特性来保护 Admin 和 RoleAdmin 控制器。清单29-23显示了对 Admin 控制器的更改。

清单 29-23:Controllers 文件夹下的 AdminController.cs 文件,限制访问

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Users.Models;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

namespace Users.Controllers 
{
    [Authorize(Roles = "Admins")]
    public class AdminController : Controller 
    {
        // ...此处省略...
    }
}

清单29-24显示了我对 RoleAdmin 控制器所做的相应更改。

清单 29-24:Controllers 文件夹下的 RoleAdminController.cs 文件,限制访问

using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Users.Models;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authorization;

namespace Users.Controllers 
{
    [Authorize(Roles = "Admins")]
    public class RoleAdminController : Controller 
    {
        // ...此处省略...
    }
}

启动应用程序并请求 /Admin 或 /RoleAdmin URL。如果您已经作为其他用户之一登录,则必须注销。否则,您将被提示输入凭据,并且可以使用密码 secret 作为 admin@example.com 进行身份验证,以访问管理功能。

总结

本章我向您展示了如何使用 ASP.NET Core Identity 对用户进行身份验证和授权。我解释了如何收集和验证凭据用户,以及如何根据用户所属的角色限制对 action 方法的访问。在下一章中,我将演示 ASP.NET Core Identity 提供的一些高级特性。

;

© 2018 - IOT小分队文章发布系统 v0.3